Block End-to-End
Q1. Device Layout
Raw block 디바이스는 크게 두 영역으로 나뉩니다. 각 영역의 역할과, data_base_offset이 가리키는 위치를 설명해 주세요. 또한 기본값 기준으로 숫자가 얼마인지도 말씀해 주세요.
A1.
디바이스는 두 영역으로 나뉩니다.
① 메타데이터 영역 — offset 0 ~ meta_total_bytes
인덱스(key→offset 맵), free slot 목록, next_slot을 JSON 체크포인트로 저장합니다.
내부적으로 _meta_copy_count = 2 (core.py L231)로 2벌 미러링됩니다:
# core.py L231-234
self._meta_copy_count: int = 2
self._meta_container_bytes: int = (
(self.meta_total_bytes // self._meta_copy_count) // self.block_align
) * self.block_align
각 컨테이너는 header 1 block + payload 구조입니다.
② 데이터 영역 — meta_total_bytes ~ end
고정 크기 슬롯의 배열입니다. _data_base_offset이 이 경계를 가리킵니다:
# core.py L988-990
self._data_base_offset = self.meta_total_bytes
data_bytes = self._effective_capacity_bytes - self._data_base_offset
self._max_slots = data_bytes // self.slot_bytes
기본값 (rust_raw_block_backend.py L217, L233-234):
| 파라미터 | 기본값 |
|---|---|
block_align | 4,096 B |
header_bytes | 4,096 B |
meta_total_bytes | 128 MB |
→ data_base_offset = 128 MB
Q2. Slot 구조와 크기 계산
slot_bytes는 어떻게 계산되나요? header_bytes, full_chunk_bytes, block_align이 각각 어떤 역할을 하는지, 그리고 왜 round_up이 필요한지 설명해 주세요.
A2.
슬롯 하나의 구조:
[ header_bytes | payload (full_chunk_bytes) | padding ]
← header_bytes →←────── slot_bytes - header_bytes ─────────→
각 파라미터 역할:
header_bytes: 슬롯 앞에 붙는 고정 헤더 크기. 내부 포맷(_encode_header, core.py L940-950):LMCBLK01(8B) +slot_identity(8B) +payload_len(8B) = 24B 최소. 기본값은 4096B — O_DIRECT 정렬을 위해 크게 잡습니다.full_chunk_bytes: L1 CPU 백엔드에서 측정한 실제 KV 텐서 1청크 크기입니다.block_align: O_DIRECT가 요구하는 정렬 단위 (보통 4096B).
slot_bytes 기본값 계산 (rust_raw_block_backend.py L259):
default_slot_bytes = round_up(header_bytes + full_chunk_bytes, block_align)
왜 round_up이 필요한가: O_DIRECT는 커널 페이지 캐시를 바이패스하는 대신 파일 offset과 I/O 크기가 모두 block_align의 배수여야 합니다. slot_bytes가 정렬되지 않으면 _slot_to_offset(slot+1) = _data_base_offset + (slot+1) * slot_bytes가 정렬되지 않아 다음 슬롯의 I/O가 실패합니다. Validation이 이를 강제합니다:
# core.py L208-213
if self.header_bytes % self.block_align != 0:
raise ValueError(...)
if self.slot_bytes % self.block_align != 0:
raise ValueError(...)
Q3. Write Path 중복 방지
batched_submit_put_task에서 동일 키에 대한 중복 쓰기를 막기 위해 두 가지 체크를 합니다. 두 체크의 차이점은 무엇이고, 왜 하나만으로는 부족한가요?
A3.
put_many L467-472의 두 체크:
with self._lock:
if key.encoded in self._index: # 체크 1
results[i] = True
continue
if key.encoded in self._inflight: # 체크 2
continue
# 여기서 슬롯 할당 + _inflight 등록
# lock 해제 후
success = self._write_one(key, obj, offset) # L494 — lock 없이 I/O 진행
두 체크의 차이:
_index 체크 | _inflight 체크 | |
|---|---|---|
| 의미 | 이미 I/O 완료 + 인덱싱됨 | 현재 다른 스레드가 I/O 중 |
| 결과 | True 반환 (성공) | False 반환 (skip) |
왜 하나만으로는 부족한가:
lock 안에서 _inflight에 등록하고 lock을 해제한 뒤 _write_one이 실행됩니다(L492→L494). 이 사이에 다른 스레드가 같은 키를 시도하면:
_index만 체크하면: I/O 완료 전이라_index에 없음 → 슬롯을 중복 할당하고 같은 키로 두 번 쓰는 race 발생_inflight만 체크하면: 이미 완료된 키(_index있음)에 대해 불필요하게 새 슬롯 할당 가능
두 체크가 합쳐서 "완료됐거나 진행 중이면 모두 skip"을 구현합니다.
Q4. Read Path — "Prefix" 의미
batched_get_blocking은 요청한 모든 키를 개별적으로 반환하지 않고, leading hit prefix만 반환합니다. 왜 이런 설계인지, 그리고 read path에서 lock=True → unlock_many 패턴이 존재하는 이유는 무엇인가요?
A4.
get_metadata_prefix L363-374:
for encoded_key in encoded_keys:
entry = self._index.get(encoded_key)
if entry is None:
break # 첫 번째 miss에서 즉시 중단
metas.append(entry.meta)
if lock:
self._lock_refcnt[encoded_key] += 1
왜 prefix만 반환하는가: LLM KV cache는 토큰 시퀀스에 1:1 대응합니다. [t0, t1, t2, t3]를 요청할 때 t1이 없으면 t2·t3는 t1의 attention 계산 없이는 쓸 수 없습니다. 중간이 빠진 히트는 의미가 없으므로, 앞에서부터 연속된 히트(leading prefix)만 반환합니다.
lock=True → unlock_many 패턴이 존재하는 이유: 로드 중에 eviction이 해당 슬롯을 재사용하면 읽어들이는 데이터가 오염됩니다. lock을 잡으면 delete_many의 force=False 경로(L674-677)에서 잠긴 키를 삭제하지 못합니다:
# core.py L674-677
locked = self._lock_refcnt.get(encoded_key, 0) > 0
if entry is not None and locked and not force:
deleted.append(False)
continue
로드가 끝난 뒤 unlock_many(L638-651)로 refcount를 감소시켜 eviction이 다시 가능해집니다.
Q5. Key Namespace와 슬롯 Identity
"legacy" 와 "object" 두 key namespace가 있습니다. 각각 언제 사용되고, slot_identity를 계산하는 방법이 왜 다른가요? (hint: 두 방식 모두 uint64입니다)
A5.
legacy namespace — encode_legacy_key (key_codec.py L118-130):
slot_identity = int(key.chunk_hash) & _UINT64_MASK
비MP(단일 프로세스) 경로의 CacheEngineKey / LayerCacheEngineKey에서 사용됩니다.
CacheEngineKey에 이미 chunk_hash(content hash) 필드가 있으므로 그것을 직접 uint64로 잘라 씁니다. 추가 해싱이 불필요합니다.
object namespace — _object_slot_identity (key_codec.py L158-168):
digest = hashlib.blake2b(encoded.encode("utf-8"), digest_size=8).digest()
return int.from_bytes(digest, "little", signed=False)
MP 경로의 ObjectKey에서 사용됩니다. encoded 형태는 model_name@kv_rank@chunk_hash[@cache_salt] 조합 문자열입니다. chunk_hash만 떼어 쓰면 model_name/kv_rank/cache_salt가 다른 키끼리 identity가 충돌할 수 있으므로, 전체 encoded string을 blake2b로 해싱해 8바이트 uint64로 만듭니다.
두 방식 모두 uint64인 이유: 슬롯 헤더에 LMCBLK01(8B) + slot_identity(8B) + payload_len(8B) = 24B 고정 포맷으로 저장되기 때문입니다(_encode_header, L940-950). 복구 시 헤더를 읽어 expected identity와 비교해 슬롯 유효성을 검증합니다(_validate_loaded_entries, L1419-1430).
보너스: Metadata Checkpoint 트리거 조건
체크포인트는 언제 저장되나요? meta_checkpoint_interval_sec과 meta_idle_quiet_ms가 각각 어떤 조건을 제어하는지 설명해 주세요.
A (보너스).
_checkpoint_once L1200-1211:
dirty = self._meta_dirty_total > self._meta_persisted
idle_ok = self._inflight_io_count == 0 and (
time.monotonic() - self._last_io_ts
) >= (self.meta_idle_quiet_ms / 1000.0)
if not dirty:
return False
if not force and not idle_ok:
return False
-
meta_checkpoint_interval_sec:_checkpoint_loop(L1025)에서_meta_stop_evt.wait(interval)— 이 주기마다 깨어나_checkpoint_once(force=False)를 호출합니다. "언제 시도할지" 의 주기입니다. -
meta_idle_quiet_ms: 마지막 I/O(_last_io_ts) 이후로_inflight_io_count == 0인 상태가 이 시간만큼 지속되어야 checkpoint가 허용됩니다. "얼마나 조용해야 쓸지" 의 조건입니다. I/O 중에 인덱스를 디스크에 쓰면 실제 슬롯 내용과 불일치할 수 있어 crash 후 복구 오류가 생기므로, 반드시 idle을 기다립니다.
체크포인트는 "주기가 됐고(interval), 더티가 있고(dirty), I/O가 없는 조용한 시점(idle_quiet)" 세 조건이 동시에 만족될 때만 기록됩니다.